# Copyright (C) 2009 Samuel Abels, http://debain.org
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
import sqlalchemy as sa
import hashlib
from Blob import Blob
from Text import Text
from Xml  import Xml
from util import mime_map, create_blob

class AddFile(object):
    def __init__(self, store, name, blob):
        self.store = store
        self.name  = name
        self.blob  = blob

    def __repr__(self):
        return 'add file %s' % self.name

    def execute(self):
        transaction = self.store.connection.begin()

        # Find the previous revision, if any.
        # Note: file.revision contains the most recent revision number.
        # revision.revision contains the revision number of the blob to
        # which it points.
        tbl_f  = self.store._table_map['file']
        tbl_r  = self.store._table_map['revision']
        tbl_b  = self.store._table_map['blob']
        table  = tbl_f.outerjoin(tbl_r, tbl_f.c.id == tbl_r.c.file_id)
        table  = table.outerjoin(tbl_b, tbl_b.c.id == tbl_r.c.blob_id)
        if self.blob.id is None:
            where = tbl_f.c.name == self.name
        else:
            where = tbl_f.c.id == self.blob.id
        select = sa.select([tbl_f.c.id,
                            tbl_f.c.name,
                            tbl_r.c.revision,
                            tbl_r.c.blob_id,
                            tbl_b.c.hash],
                           sa.and_(where, tbl_f.c.revision != 0),
                           from_obj   = [table],
                           order_by   = [sa.desc(tbl_r.c.revision)],
                           use_labels = True,
                           limit      = 1)
        result        = select.execute()
        row           = result.fetchone()
        file_id       = row and row[tbl_f.c.id]       or None
        last_name     = row and row[tbl_f.c.name]     or None
        last_revision = row and row[tbl_r.c.revision] or 0
        last_blob_id  = row and row[tbl_r.c.blob_id]  or None
        last_hash     = row and row[tbl_b.c.hash]     or None

        # Do nothing if the name and blob are unchanged.
        blob_data = self.blob.bin()
        blob_text = str(self.blob)
        blob_hash = hashlib.sha224(blob_data).hexdigest()
        if last_hash == blob_hash and last_name == self.name:
            transaction.commit()
            return

        # If this is the first revision, create the new file, else update
        # the revision number.
        if file_id is None:
            insert  = self.store._table_map['file'].insert()
            result  = insert.execute(name     = self.name,
                                     revision = last_revision + 1,
                                     deleted  = False)
            file_id = result.last_inserted_ids()[0]
        else:
            table  = self.store._table_map['file']
            update = table.update(table.c.id == file_id)
            update.execute(name     = self.name,
                           revision = last_revision + 1,
                           deleted  = False)
        self.blob.revision = last_revision + 1
        self.blob.deleted  = False

        # Only create a new blob if it changed.
        if last_hash == blob_hash:
            blob_id = last_blob_id
        else:
            # Look for another blob with the same hash.
            tbl_b  = self.store._table_map['blob']
            where  = sa.and_(tbl_b.c.hash      == blob_hash,
                             tbl_b.c.mime_type == self.blob.mime_type)
            select = sa.select([tbl_b.c.id], where, from_obj = [tbl_b], limit = 1)
            result = select.execute()
            row    = result.fetchone()

            if row:
                # We found one. Re-use it to save space.
                blob_id = row[tbl_b.c.id]
            else:
                # None found. Insert the blob into the blob table.
                insert  = self.store._table_map['blob'].insert()
                result  = insert.execute(content      = blob_data,
                                         content_text = blob_text,
                                         hash         = blob_hash,
                                         mime_type    = self.blob.mime_type)
                blob_id = result.last_inserted_ids()[0]

        # Map the revision to the blob.
        insert  = self.store._table_map['revision'].insert()
        result  = insert.execute(file_id  = file_id,
                                 blob_id  = blob_id,
                                 revision = last_revision + 1)

        transaction.commit()


class DeleteFileFromName(object):
    def __init__(self, store, name):
        self.store = store
        self.name  = name

    def __repr__(self):
        return 'delete file %s' % self.name

    def execute(self):
        # Delete the file.
        table  = self.store._table_map['file']
        update = table.update(table.c.name == self.name)
        update.execute(deleted = True)


class DeleteFile(object):
    def __init__(self, store, blob):
        self.store = store
        self.blob  = blob

    def __repr__(self):
        return 'delete file %s' % self.blob.name

    def execute(self):
        # Delete the file.
        table  = self.store._table_map['file']
        update = table.update(table.c.name == self.blob.name)
        update.execute(deleted = True)
        self.blob.deleted = True


class UndeleteFile(object):
    def __init__(self, store, name):
        self.store = store
        self.name  = name

    def __repr__(self):
        return 'undelete file %s' % self.name

    def execute(self):
        transaction = self.store.connection.begin()

        # Delete the file by setting it's revision number to 0.
        table  = self.store._table_map['file']
        update = table.update(table.c.name == self.name)
        result = update.execute(deleted = False)
        if result.rowcount == 0:
            raise Exception('Undelete on a non-existent file.')

        transaction.commit()


class PurgeFile(object):
    def __init__(self, store, name):
        self.store = store
        self.name  = name

    def __repr__(self):
        return 'purging file %s' % self.name

    def execute(self):
        transaction = self.store.connection.begin()

        #FIXME: This deletes the history of a file, but not the blobs.
        # We can't just delete all associated blobs as they may be
        # shared with other files. The only way to purge properly
        # is to iterate over all revisions and delete only the
        # related blobs that are not referenced by any other file.
        table  = self.store._table_map['file']
        where  = table.c.name == self.name
        delete = table.delete(where)
        delete.execute()

        transaction.commit()


class Store(object):
    def __init__(self, filename):
        """
        Instantiates a new Store.
        
        @type  filename: string
        @param filename: The file in which data is stored.
        @rtype:  Store
        @return: The new instance.
        """
        self.db            = sa.create_engine('sqlite:///' + filename)
        self.db_metadata   = sa.MetaData(self.db)
        self.connection    = self.db.connect()
        self._table_prefix = 'warehouse_'
        self._table_map    = {}
        self._staged       = [] # Actions to be taken at the next commit()
        self.__update_table_names()
        self._install()


    def __add_table(self, table):
        """
        Adds a new table to the internal table list.
        
        @type  table: Table
        @param table: An sqlalchemy table.
        """
        pfx = self._table_prefix
        self._table_map[table.name[len(pfx):]] = table


    def __update_table_names(self):
        """
        Adds all tables to the internal table list.
        """
        pfx = self._table_prefix
        self.__add_table(sa.Table(pfx + 'blob', self.db_metadata,
            sa.Column('id',           sa.Integer,    primary_key = True),
            sa.Column('content',      sa.Binary()),
            sa.Column('content_text', sa.Text,       index = True),
            sa.Column('hash',         sa.String(50), index = True),
            sa.Column('mime_type',    sa.String(50))
        ))
        self.__add_table(sa.Table(pfx + 'file', self.db_metadata,
            sa.Column('id',       sa.Integer,     primary_key = True),
            sa.Column('revision', sa.Integer,     index = True),
            sa.Column('deleted',  sa.Boolean,     index = True),
            sa.Column('name',     sa.String(230), index = True)
        ))
        self.__add_table(sa.Table(pfx + 'revision', self.db_metadata,
            sa.Column('file_id',   sa.Integer,    primary_key = True),
            sa.Column('blob_id',   sa.Integer,    primary_key = True),
            sa.Column('revision',  sa.Integer,    primary_key = True),
            sa.ForeignKeyConstraint(['file_id'],
                                    [pfx + 'file.id'],
                                    ondelete = 'CASCADE'),
            sa.ForeignKeyConstraint(['blob_id'],
                                    [pfx + 'blob.id'],
                                    ondelete = 'CASCADE')
        ))


    def debug(self, debug = True):
        """
        Enable/disable debugging.

        @type  debug: Boolean
        @param debug: True to enable debugging.
        """
        if debug:
            self.db.echo = 1
        else:
            self.db.echo = 0


    def set_table_prefix(self, prefix):
        """
        Define a string that is prefixed to all table names in the database.
        Default is 'guard_'.

        @type  prefix: string
        @param prefix: The new prefix.
        """
        self._table_prefix = prefix
        self.__update_table_names()


    def get_table_prefix(self):
        """
        Returns the current database table prefix.
        
        @rtype:  string
        @return: The current prefix.
        """
        return self._table_prefix


    def _install(self):
        """
        Installs (or upgrades) database tables.

        @rtype:  Boolean
        @return: True on success, False otherwise.
        """
        self.db_metadata.create_all()
        return True


    def uninstall(self):
        """
        Drops all tables from the database. Use with care.

        @rtype:  Boolean
        @return: True on success, False otherwise.
        """
        self.db_metadata.drop_all()
        return True


    def clear(self):
        """
        Drops the content of any database table used by this library.
        Use with care.

        @rtype:  Boolean
        @return: True on success, False otherwise.
        """
        delete = self._table_map['file'].delete()
        delete.execute()
        delete = self._table_map['blob'].delete()
        delete.execute()
        return True


    def add(self, name, blob):
        """
        Adds the blob with the given name.

        @type  name: string
        @param name: The name under which the blob is stored.
        """
        if name is None:
            raise AttributeError('name argument must not be None')
        if blob is None:
            raise AttributeError('blob argument must not be None')
        if blob.mime_type not in mime_map:
            raise AttributeError('blob has an unknown mime type')
        self._staged.append(AddFile(self, name, blob))


    def delete(self, blob):
        """
        Deletes the given blob.

        @type  blob: Blob
        @param blob: The blob that is deleted.
        """
        if blob is None:
            raise AttributeError('blob argument must not be None')
        if blob.name is None:
            raise AttributeError('blob\'s name attribute must not be None')
        self._staged.append(DeleteFile(self, blob))


    def delete_from_name(self, name):
        """
        Marks a blob as deleted.

        @type  name: string
        @param name: The name of the blob that is deleted.
        """
        if name is None:
            raise AttributeError('name argument must not be None')
        self._staged.append(DeleteFileFromName(self, name))


    def undelete(self, name):
        """
        Marks a blob as not deleted.

        @type  name: string
        @param name: The name of the blob that is restored.
        """
        if name is None:
            raise AttributeError('name argument must not be None')
        self._staged.append(UndeleteFile(self, name))


    def purge(self, name):
        """
        Completely removes a blob, including its history. The operation
        can not be reversed. Use with care.

        @type  name: string
        @param name: The name of the blob that is deleted.
        """
        if name is None:
            raise AttributeError('name argument must not be None')
        self._staged.append(PurgeFile(self, name))


    def _create_blob_from_row(self, row, default = None):
        if row is None:
            return default
        tbl_f = self._table_map['file']
        tbl_r = self._table_map['revision']
        tbl_b = self._table_map['blob']
        blob          = create_blob(row[tbl_b.c.mime_type],
                                    row[tbl_b.c.content])
        blob.id       = row[tbl_f.c.id]
        blob.name     = row[tbl_f.c.name]
        blob.deleted  = row[tbl_f.c.deleted]
        blob.revision = row[tbl_r.c.revision]
        return blob


    def get(self, name, default = None, revision = None, deleted = False):
        """
        Returns the blob with the given name, and optionally revision number.
        Deleted files are only returned if the "deleted" argument is True.

        @type  name: string
        @param name: The name of the blob that is returned.
        @type  default: object
        @param default: The value that is returned if the blob is not found.
        @type  revision: int
        @param revision: The version of the blob that is returned.
        @type  deleted: bool
        @param deleted: True if deleted objects are included in the result.
        @rtype:  Blob
        @return: The blob, or the given default value if none was found.
        """
        if name is None:
            raise AttributeError('name argument must not be None')
        tbl_f  = self._table_map['file']
        tbl_r  = self._table_map['revision']
        tbl_b  = self._table_map['blob']
        table  = tbl_f.outerjoin(tbl_r, tbl_f.c.id == tbl_r.c.file_id)
        table  = table.outerjoin(tbl_b, tbl_b.c.id == tbl_r.c.blob_id)
        fields = [tbl_f.c.id,
                  tbl_f.c.name,
                  tbl_f.c.deleted,
                  tbl_r.c.revision,
                  tbl_b.c.content,
                  tbl_b.c.hash,
                  tbl_b.c.mime_type]
        where  = sa.and_(tbl_f.c.name == name)
        if revision:
            where = sa.and_(where, tbl_r.c.revision == revision)
        if not deleted:
            where = sa.and_(where, tbl_f.c.deleted == False)
        select = sa.select(fields,
                           where,
                           order_by   = [sa.desc(tbl_r.c.revision)],
                           from_obj   = [table],
                           use_labels = True,
                           limit      = 1)
        result = select.execute()
        row    = result.fetchone()
        return self._create_blob_from_row(row, default)


    def get_from_id(self, id, revision = None):
        """
        Returns the blob with the given id, and optionally revision number.

        @type  id: int
        @param id: The id of the blob that is returned.
        @type  revision: int
        @param revision: The version of the blob that is returned.
        @rtype:  Blob
        @return: The blob with the given id, or None.
        """
        if id is None:
            raise AttributeError('id argument must not be None')
        tbl_f  = self._table_map['file']
        tbl_r  = self._table_map['revision']
        tbl_b  = self._table_map['blob']
        table  = tbl_f.outerjoin(tbl_r, tbl_f.c.id == tbl_r.c.file_id)
        table  = table.outerjoin(tbl_b, tbl_b.c.id == tbl_r.c.blob_id)
        fields = [tbl_f.c.id,
                  tbl_f.c.name,
                  tbl_f.c.deleted,
                  tbl_r.c.revision,
                  tbl_b.c.content,
                  tbl_b.c.hash,
                  tbl_b.c.mime_type]
        select = sa.select(fields,
                           tbl_f.c.id == id,
                           order_by   = [sa.desc(tbl_r.c.revision)],
                           from_obj   = [table],
                           use_labels = True,
                           limit      = 1)
        result = select.execute()
        row    = result.fetchone()
        return self._create_blob_from_row(row)


    def get_data(self, name, default = None, revision = None, deleted = False):
        """
        A convenience wrapper around get() that returns the content of
        the blob, not the blob object.

        @type  name: string
        @param name: The name of the blob that is returned.
        @type  default: object
        @param default: The value that is returned if the blob is not found.
        @type  revision: int
        @param revision: The version of the blob that is returned.
        @type  deleted: bool
        @param deleted: True if deleted objects are included in the result.
        @rtype:  string
        @return: The blob content if found, else the given default value.
        """
        blob = self.get(name, revision, deleted)
        if blob is None:
            return default
        return blob.bin()


    def exists(self, name, revision = None, deleted = False):
        """
        Returns True if a blob with the given name exists, False otherwise.
        Deleted blobs are only searched if the "deleted" argument is True.

        @type  name: string
        @param name: The name of the blob.
        @type  revision: int
        @param revision: The version of the blob.
        @type  deleted: bool
        @param deleted: True if deleted objects are included in the result.
        @rtype:  boolean
        @return: True if the blob was found, False otherwise.
        """
        tbl_f  = self._table_map['file']
        tbl_r  = self._table_map['revision']
        table  = tbl_f.outerjoin(tbl_r, tbl_f.c.id == tbl_r.c.file_id)
        where  = tbl_f.c.name == name
        if revision:
            where = sa.and_(where, tbl_r.c.revision == revision)
        if not deleted:
            where = sa.and_(where, tbl_f.c.deleted == False)
        select = sa.select([tbl_f.c.id],
                           where,
                           from_obj   = [table],
                           use_labels = True,
                           limit      = 1)
        result = select.execute()
        if result.fetchone() is None:
            return False
        return True


    def find(self,
             name    = None,
             limit   = -1,
             offset  = 0,
             deleted = False,
             content = None):
        """
        Returns the most recent version of the object with the given name.
        Deleted files are not included in the result.

        @type  name: string
        @param name: The name of the blob. '%' wildcards are allowed.
        @type  limit: int
        @param limit: The maximum number of results that are returned.
        @type  offset: int
        @param offset: The offset of the first result that is returned.
        @rtype:  list[Blob]
        @return: The list of results.
        """
        tbl_f  = self._table_map['file']
        tbl_r  = self._table_map['revision']
        tbl_b  = self._table_map['blob']
        table  = tbl_f.outerjoin(tbl_r, tbl_f.c.id == tbl_r.c.file_id)
        table  = table.outerjoin(tbl_b, tbl_b.c.id == tbl_r.c.blob_id)
        where  = tbl_f.c.revision == tbl_r.c.revision
        if name:
            where = sa.and_(where, tbl_f.c.name.like(name))
        if not deleted:
            where = sa.and_(where, tbl_f.c.deleted == False)
        if content:
            where = sa.and_(where, tbl_b.c.content_text.like(str(content)))
        fields = [tbl_f.c.id,
                  tbl_f.c.name,
                  tbl_f.c.deleted,
                  tbl_r.c.revision,
                  tbl_b.c.content,
                  tbl_b.c.hash,
                  tbl_b.c.mime_type]
        select = sa.select(fields,
                           where,
                           from_obj   = [table],
                           use_labels = True,
                           limit      = limit,
                           offset     = offset)
        blobs  = []
        for row in select.execute():
            blob = create_blob(row[tbl_b.c.mime_type], row[tbl_b.c.content])
            blob.id       = row[tbl_f.c.id]
            blob.name     = row[tbl_f.c.name]
            blob.deleted  = row[tbl_f.c.deleted]
            blob.revision = row[tbl_r.c.revision]
            blobs.append(blob)
        return blobs


    def commit(self, message = ''):
        """
        Commit messages are ignored so far.

        @type  message: string
        @param message: A message for the commit log.
        """
        transaction = self.connection.begin()
        for action in self._staged:
            action.execute()
        transaction.commit()
        self._staged = []


    def reset(self):
        """
        Removes any staged actions.
        """
        self._staged = []


    def status(self):
        """
        Returns a list of human readable strings that explain what is
        done when the next commit() call is made.

        @type  message: string
        @param message: A message for the commit log.
        @rtype:  list[string]
        @return: A list of status messages.
        """
        return [repr(action) for action in self._staged]


    def close(self):
        """
        Closes the database connection. The store instance can no longer be
        used after calling this method.
        """
        self.connection.close()
